Skip to content

fix(runtime): respect keepPlaying option in player seek#863

Open
calcarazgre646 wants to merge 1 commit into
heygen-com:mainfrom
calcarazgre646:fix/runtime-seek-keep-playing
Open

fix(runtime): respect keepPlaying option in player seek#863
calcarazgre646 wants to merge 1 commit into
heygen-com:mainfrom
calcarazgre646:fix/runtime-seek-keep-playing

Conversation

@calcarazgre646
Copy link
Copy Markdown
Contributor

Summary

Closes the follow-up I promised in #842 (comment): the __player runtime adapter ignored the { keepPlaying: true } option that A/E Jump-to-in/out shortcuts send, so playback pauses on every press even though the caller asked to preserve it. Only the wrapTimeline (GSAP) path honoured the option.

Background

PR #842 added seek: (time, options?: { keepPlaying?: boolean }) to PlaybackAdapter and wired the A/E shortcuts in usePlaybackKeyboard to pass { keepPlaying: true }. The fix landed correctly for compositions backed by __timeline / __timelines (GSAP), because wrapTimeline reads the option and skips its internal tl.pause().

For compositions backed by window.__player (packages/core/src/runtime/init.ts:1529), useTimelinePlayer.getAdapter() returns the runtime adapter directly without a wrapper. That adapter's seek is basePlayer.seek from createRuntimePlayer (packages/core/src/runtime/player.ts:147), and the implementation was:

seek: (timeSeconds: number) => {
  // ...quantize + deterministic seek...
  deps.setIsPlaying(false);
  deps.onSyncMedia(quantized, false);
  // ...
},

setIsPlaying(false) runs unconditionally, so A/E pauses the moment the shortcut fires. The option that the Studio sends never reaches the implementation because both the local basePlayer shape in createPlayerApiCompat and the public PlayerAPI.seek / RuntimePlayer.seek types only declared (time: number) => void.

Fix

  1. Extend the type contract from end to end so the option is preserved through createPlayerApiCompat:

    • core.types.tsPlayerAPI.seek(time, options?: { keepPlaying?: boolean })
    • runtime/types.tsRuntimePlayer.seek signature
    • runtime/init.ts:88 — local basePlayer shape in createPlayerApiCompat
  2. Implement the behaviour in createRuntimePlayer.seek:

    • Capture wasPlaying before the deterministic seek helper pauses the master and rearmed siblings.
    • When options.keepPlaying && wasPlaying, resume the master + siblings, reapply playbackRate via timeScale, and emit onDeterministicPlay + onShowNativeVideos + onSyncMedia(t, true) so media and analytics stay in sync.
    • Default branch (no options, explicit keepPlaying: false, or wasPlaying === false) keeps the existing setIsPlaying(false) + onSyncMedia(t, false) path. No back-compat change.

Test plan

Added 6 new tests in packages/core/src/runtime/player.test.ts under describe(\"keepPlaying option\"):

  • Preserves play state when keepPlaying: true and playback was active (no setIsPlaying(false), emits onDeterministicPlay + onSyncMedia(t, true)).
  • Resumes the master timeline after the deterministic seek pauses it (asserts play() is invoked after the last internal pause() via invocationCallOrder).
  • Applies playbackRate to master and siblings on resume.
  • Stays paused when keepPlaying: true but playback was not active (intent is preserve, not force).
  • Explicit keepPlaying: false matches default behaviour.
  • No options matches default behaviour (back-compat).

Other validations:

  • bun run --cwd packages/core test → 868/868 passing (was 862, +6 new).
  • bun run --cwd packages/studio test → 514/514 passing, no regression in the consuming side.
  • bun run --cwd packages/core typecheck and bun run --cwd packages/studio typecheck clean.
  • bunx oxlint and bunx oxfmt --check clean on touched files.
  • Lefthook pre-commit ran lint + format + typecheck + commitlint.

Out of scope

createStaticSeekPlaybackAdapter in packages/studio/src/player/lib/playbackAdapter.ts still has seek: (time) => void (no options). Its current behaviour happens to preserve playback by accident (the playing flag drives the RAF ticker independently), but the contract is mismatched. Happy to follow up if a maintainer wants the static-seek adapter brought in line with the same shape.

Related

The runtime player's seek unconditionally pauses on every invocation, so
A/E Jump-to-in/out shortcuts (which pass { keepPlaying: true } per PR
heygen-com#842) pause playback in compositions backed by the __player runtime
adapter. Only the wrapTimeline path honoured the option.

Extend RuntimePlayer.seek and the PlayerAPI contract to accept
{ keepPlaying?: boolean }. When keepPlaying is set and playback was
active, resume the master plus rearmed sibling timelines and emit the
play-state events so media and analytics stay in sync. Default behaviour
is unchanged when no options are passed.
Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

Approve. Clean, narrow fix that closes the asymmetry left by #842: the __player runtime adapter now honours { keepPlaying: true } so A/E Jump-to-in/out shortcuts preserve playback for window.__player-backed compositions, just like the wrapTimeline (GSAP) path already did. Type contract is propagated end-to-end (PlayerAPIRuntimePlayercreatePlayerApiCompat basePlayer shape), implementation mirrors the existing play() body, and the 6 new tests cover the meaningful branches including the wasPlaying === false no-op and the explicit keepPlaying: false back-compat case.

Spot-checked claims against source:

  • useTimelinePlayer.getAdapter() does return win.__player directly (no wrapper) — usePlaybackKeyboard.ts A/E handlers do pass { keepPlaying: true }wrapTimeline.seek skips its internal tl.pause() when keepPlaying is set. All check out.
  • seekMasterAndSiblingTimelinesDeterministically does pause the master and re-pause rearmed siblings, so the new branch correctly has to call timeline.play() + iterate siblings to resume — matches the symmetric pattern in play() (lines 117–129).
  • wasPlaying is captured before the deterministic seek; the helper manipulates timelines but never touches isPlaying state, so the read is accurate.

Key Concerns

None blocking.

Test Coverage

Solid. The invocationCallOrder assertion in "resumes the master timeline after the deterministic seek pauses it" is a good way to express "play() must come after the helper's pause()" — that's exactly the regression vector. Coverage:

  • keepPlaying: true && wasPlaying → preserves state, emits onDeterministicPlay + onShowNativeVideos + onSyncMedia(_, true)
  • keepPlaying: true && !wasPlaying → stays paused (intent is preserve, not force) ✓
  • keepPlaying: false explicit → default path ✓
  • No options → default path (back-compat) ✓
  • Sibling timeScale + play() propagation ✓
  • Master play() ordered after the helper's pause()

Gap (non-blocking): no studio-side integration test in useTimelinePlayer.seek.test.ts exercising A/E → adapter.seek(_, { keepPlaying: true }) against the runtime adapter. The unit coverage is sufficient given options pass straight through useTimelinePlayer.seekadapter.seek and the studio test suite passes, but a single end-to-end assertion would catch a future delegate regression.

Nits / Future

  1. createStaticSeekPlaybackAdapter type drift (out of scope, called out in PR). Worth tightening the type now — it's a one-line change and the body already preserves playback by accident. Leaving the type signature mismatched is a footgun for the next person who reads the adapter and thinks keepPlaying is unhandled. Happy to follow up in a separate PR if you'd rather keep this one tight.

  2. Sibling play→pause→play churn. During a keepPlaying seek, each sibling sees play() (rearm) → pause() (helper finally block) → play() (this branch). Functionally correct, but three GSAP state transitions per seek per sibling. Plumbing keepPlaying into seekMasterAndSiblingTimelinesDeterministically so it skips the final pause when the caller is going to immediately resume would be cleaner. Optimization, not a bug.

  3. onDeterministicPause is never emitted by seek(), regardless of branch. This matches the prior behaviour (the original seek only emitted onDeterministicSeek), so no regression — flagging only because the play/pause methods do emit their paired onDeterministicPlay/onDeterministicPause, and an external listener relying on pair-symmetry could be surprised. Probably intentional, but worth being aware of.

  4. setIsPlaying(true) is not called in the keep-playing branch. Fine because wasPlaying === true means state is already true, but it makes the branch slightly asymmetric to play() (which always emits setIsPlaying(true)). Not worth changing — just noting for future readers.

Nice cleanup of the loose end from #842.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants